fix: stop re-downloading and re-parsing the full transcript on every watcher tick - #1
Open
sigalor wants to merge 7 commits into
Open
fix: stop re-downloading and re-parsing the full transcript on every watcher tick#1sigalor wants to merge 7 commits into
sigalor wants to merge 7 commits into
Conversation
…watcher tick An actively-working session's transcript grows continuously, and the filesystem watcher fires a `session_upserted` event on every change while it's the currently-viewed session. The client reacted to that by calling refreshFromServer(), which fetched the entire, unbounded message history on every single tick — and the server's getSessionMessages() re-read and re-JSON.parsed the whole JSONL file (plus every referenced subagent agent-*.jsonl file) from scratch each time, regardless of the caller's requested limit. Together this meant network transfer and CPU cost per tick scaled with total session size instead of with how much actually changed, turning a long-running session into a continuous multi-MB refetch loop. - refreshFromServer now requests a bounded tail window (TAIL_REFRESH_LIMIT) and merges only genuinely-new messages by id, falling back to a full fetch only if more messages appeared in one tick than the window covers. - getSessionMessages/parseAgentTools now cache parsed JSONL lines per file (keyed by size+mtime) and read only newly-appended bytes on repeat calls, instead of re-reading and re-parsing from byte 0 every time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mergeTailMessages (introduced in 47828c8) appended not-yet-seen tail messages to the END of the already-loaded array, assuming they were always newer. That's false whenever the loaded page is smaller than the tail window: refreshFromServer's tail request commonly reaches further back than fetchFromServer's initial page (e.g. any session small enough that its whole history fits inside TAIL_REFRESH_LIMIT), so the "new" messages it surfaces are actually OLDER than what's already showing, not newer — they were landing after the newest messages instead of before them. Symptoms in production: messages rendering out of order and reshuffling on every watcher tick ("jumping around"), assistant replies whose content the model itself referenced not appearing where expected, and the chat only looking right immediately after sending a message (the one path that still does a full, correctly-ordered reload). mergeTailMessages now merges by id (tail's copy wins on overlap, so content that updates after the fact — e.g. a tool_result's subagentTools gaining entries — isn't stuck stale) and re-sorts the result chronologically instead of trusting concatenation order. The pure ordering/merge logic (readMessageTime, compareMessagesChronologically, mergeTailMessages) is split into sessionMessageOrdering.ts specifically so it can be covered by a real test — this project has no frontend test runner, and useSessionStore.ts itself can't be imported outside Vite (import.meta.env). The new test reproduces the exact bug shape (a 2-message loaded page vs. a 5-message tail window) and is confirmed to fail against the old concatenation-only logic before this fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cloud-admin-box's 2026-09-03 model pin only patched ~/.claude/settings.json, which the interactive `claude` CLI reads but this app's own SDK-driven sessions never consult (claude-sdk.js always passes an explicit `model` option, which overrides settingSources resolution). CLAUDE_FALLBACK_MODELS.DEFAULT was the literal string 'default', which this file's own OPTIONS list defines as "the Claude Code default model (currently Sonnet 4.6)" — independent of any settings.json. Point the fallback at 'fable' directly so sessions with no explicit per-session model override get Fable, matching the operator's standing preference for this box. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
v1.36.2-divizend.3 changed the backend's CLAUDE_FALLBACK_MODELS.DEFAULT, but
verification against a live cloud-admin-box session after deploying it showed
sessions were still landing on Sonnet. Root cause: the chat websocket handler
spreads the client's `data.options` verbatim into queryClaudeSDK's options
(chat-websocket.service.ts), and the frontend's claudeModel state is *never*
empty — it's either a cached localStorage['claude-model'] value or the
hardcoded FALLBACK_DEFAULT_MODEL.claude ('default' literal). So
`options.model || CLAUDE_FALLBACK_MODELS.DEFAULT` on the backend almost never
reaches its fallback branch; the client's explicit 'default' wins every time,
independent of that backend constant.
Fixes, for real this time:
- FALLBACK_DEFAULT_MODEL.claude (frontend) now matches CLAUDE_FALLBACK_MODELS.
DEFAULT (backend): the authoritative pinned API model id
'claude-fable-5-1', not a generic 'fable' alias that could drift to a
different snapshot later, and not the frontend's separate 'default'
literal that bypassed the backend fallback entirely.
- The Fable OPTIONS entry's `value` is renamed to match, so
resolveClaudeEffort's lookup still matches and the default session keeps
Fable's declared effort levels instead of silently losing effort
resolution.
- claudeModel's localStorage-backed init now goes through
resolveInitialProviderModel (new src/stores/providerModelDefaults.ts),
which treats an already-cached literal 'default' the same as "no real
preference" and falls through to the new fallback — so browsers that
loaded this app before today also pick up Fable on next load, not just
brand-new ones. Pure function + Node-runnable test, following the
precedent set by sessionMessageOrdering.ts after the last frontend-only
regression shipped without coverage.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fable as the default (v1.36.2-divizend.4, earlier today) burned tokens far too fast; operator asked for Sonnet again the same day. A plain constant flip would not have worked: the reconcile effect in useChatProviderState wrote the *resolved default* back to localStorage['claude-model'] unconditionally, so every browser that loaded today's build now holds 'claude-fable-5-1' there and would have been treated as an explicit pick — the mirror image of the 'default'-literal trap the .4 fix was for. So: - Both defaults (backend CLAUDE_FALLBACK_MODELS.DEFAULT, frontend FALLBACK_DEFAULT_MODEL.claude) now point at 'claude-sonnet-5' by exact API id, and the Sonnet OPTIONS entry is renamed to match so effort resolution keeps working. Fable stays selectable. - normalizeStoredProviderModel treats 'default' and 'claude-fable-5-1' as persisted-fallback sentinels (never a deliberate pick — the per-session model-change cache had never been written before today), used by both the useState initializer and pickStoredOrCurrent, so the catalog reconcile can't re-pin a browser to a fallback it has moved away from. - The reconcile effect only persists explicit picks now; a fallback-only state clears the key instead of writing the default into it. That is what had frozen every browser on whatever the default was at first load. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Read once at process start (cloud-admin-box rollout-restarts on a switch). A valid but unlisted id gets a synthesized picker entry so effort resolution keeps matching; unset/invalid falls back to claude-sonnet-5 (invalid logs a startup warning). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nventing one Sends `model` only for an explicit pick; displays the catalog DEFAULT otherwise; refetches the catalog on websocket reconnect so open tabs follow a switch; picking the catalog default clears the explicit pick. Also fixes an import-order lint warning introduced by the previous commit's new import in claude-models.provider.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
refreshFromServer(client) requested the entire unbounded message history on every filesystem-watcher-triggered refresh; now requests a bounded tail window and merges only new messages by id.getSessionMessages/parseAgentTools(server) re-read and re-parsed the whole JSONL transcript (and every subagent file) from scratch on every call, regardless of the caller's limit; now cache parsed lines per file (keyed by size+mtime) and read only newly-appended bytes.Together these turned an actively-worked, long-running session into a continuous multi-MB full-transcript refetch loop (observed: ~30MB / ~45s per
/messagescall, repeating every couple of seconds) — this fork exists to carry this fix on top of the pinned v1.36.2 release used internally.Test plan
npm run typechecknpm run lintnpm run buildnpx tsx --tsconfig server/tsconfig.json --test server/**/*.test.ts ...— 116/116 passing (115 pre-existing + 1 new test covering the cache's incremental-append, idempotent-repeat, and shrink-fallback behavior)